背景

  1. SEO 需求

我们现在的网站是 CSR 渲染的,但有很多页面的内容密度较大(如:文献详情页、学者详情页),想要改造成 SSR 渲染以优化 SEO,增加产品曝光量

由 CSR 改造成 SSR 的成本并不小,基本方案是引入 Next.js,这需要对原代码从架构上进行改造

  1. 架构升级

我们现在的项目基本是和 Web 端强绑定的,如果想要扩展到小程序、移动端、桌面端是很难复用的

所以借由此契机,我们对整个项目的架构进行一些调整升级,以力求能够提高项目的健壮性、可复用性和可扩展性

现状

Pasted image 20260305160352.png

目标

Pasted image 20260305160418.png

  1. UI 层不处理任何业务逻辑,只做展示,需要具备 stateless 能力
  2. 业务逻辑层包括各种业务处理,数据处理,状态维护等
  3. 数据总线,各子模块之间的事件传递通过数据总线通信,下层不允许直接调用上层(通用层不可以调业务逻辑层),需要通过数据总线通信
  4. 除平台层,其他层禁止直接使用平台相关 API,如 window.xxx, wechat.xxx 等。可以预留接口,由平台层注入平台相关 API

想法

workspce 结构

├── bohrium-shared      # 通用层
├── bohrium-domains     # 业务层:上图中的业务逻辑层+业务UI层
├── bohrium-space       # 平台层:Web/H5 CSR
├── bohrium-next-app    # 平台层:Web/H5 SSR

# 之后可能会加
├── bohrium-wx-applet   # 平台层:微信小程序
├── bohrium-electron    # 平台层:桌面应用
├── bohrium-mobile      # 平台层:移动端

# 现在 Web/H5 都混在一个包中了,可以考虑单分一个 H5 包
├── bohrium-h5          # 平台层:H5
......

@bohrium/shared

通用层:业务无关、平台无关、无状态和副作用(hooks)

├── bohrium-shared
│   ├── ui/           # UI Widgets
│   │  ├── Button/        
│   │  └── Modal/                
│   ├── utils/        # 工具函数
│   │  └── EventBus/ # 事件总线
│   ├── constants/    # 常量
│   ├── types/        # TS 类型定义
│   ├── icons/        # icons

ui

  1. 只负责 UI 展示,无任何逻辑、状态和副作用
  2. 只是最基础的通用小组件,而不是业务组件

如下图中的 Text、Icon 等,而不是整个卡片

整个卡片应该放到业务层,再用这里的小组件“拼积木”

Pasted image 20260305160548.png

utils

事件总线:

// 发布订阅
type ObjectKey = string | number | symbol;
type AnyFunction = (...args: any[]) => any;

class EventBus {
    // Map<eventName, Map<originalCallback, callback>>
    // originalCallback 用于取消 once 注册的回调
    private events: Map<ObjectKey, Map<AnyFunction, AnyFunction>>;

    constructor() {
        this.events = new Map();
    }

    // 使用泛型的原因:...args 有类型,方便代码提示和类型检查
    emit<T extends AnyFunction>(eventName: ObjectKey, ...args: Parameters<T>) {
        const events = this.events.get(eventName);
        if (!events) {
            return;
        }
        events.forEach(callback => {
            callback(...args);
        });
    }

    on<T extends AnyFunction>(eventName: ObjectKey, callback: T) {
        this._on(eventName, callback, callback);
    }

    once<T extends AnyFunction>(eventName: ObjectKey, callback: T) {
        // 包装方法:执行一次后取消订阅
        const wrapper = (...args: Parameters<T>) => {
            callback(...args);
            this.off(eventName, callback);
        };
        this._on(eventName, wrapper, callback);
    }

    off<T extends AnyFunction>(eventName: ObjectKey, callback: T): boolean {
        const events = this.events.get(eventName);
        if (!events) {
            return false;
        }
        return events.delete(callback);
    }

    offAll(eventName: ObjectKey): boolean {
        return this.events.delete(eventName);
    }

    private _on(eventName: ObjectKey, callback: AnyFunction, originalCallback: AnyFunction) {
        let events = this.events.get(eventName);
        if (!events) {
            events = new Map();
            this.events.set(eventName, events);
        }
        events.set(originalCallback, callback);
    }
}

export const eventBus = new EventBus();

平台接口:

// 平台相关的 PAI 接口定义,代码中调用这个接口中的方法
export const platform = {
    // 打开新页面
    openNewPage(url: string | URL, opts: any) {},
    // 从本地存储取值
    getItemFromStorage<T>(key: string, opts: any): Promise<T> {},
    // ...
}
// 平台层注入具体实现

// web
function init() {
    platform.openNewPage = window.open
    platform.getItemFromStorage(key: string, opts: any) {
        const { target = 'local' } = opts || {}
        switch (target) {
            case 'local':
                return Promise.resolve(window.localStorage.getItem(key))
            // ...
        }
    }
}

// electron
function init() {
    platform.openNewPage = require('electron').shell.openExternal
    platform.getItemFromStorage(key: string, opts: any) {
        const { target = 'local' } = opts || {}
        switch (target) {
            case 'local':
                return require('fs').promises.readFile('/config.db')
            // ...
        }
    }
}

Hooks

事件总线:
export function useEventSubscribe(eventName: ObjectKey, callback: AnyFunction, once: boolean = false) {
    useEffect(() => {
        if (once) {
            eventBus.once(eventName, callback);
        } else {
            eventBus.on(eventName, callback);
        }
        return () => {
            eventBus.off(eventName, callback);
        };
    }, [eventName, callback, once]);
}



// 升级版
### ✅ 方案:多表 + 泛型表参数

#### 1️⃣ 按业务拆分事件定义


// events/auth.events.ts
export interface AuthEvents {
  login: { userId: number; userName: string };
  logout: undefined;
}

// events/theme.events.ts
export interface ThemeEvents {
  themeChanged: { mode: 'light' | 'dark' };
}

// events/order.events.ts
export interface OrderEvents {
  orderCreated: { orderId: string; amount: number };
  orderPaid: { orderId: string };
}


#### 2️⃣ 改造 Hook:把“表”变成泛型参数


// eventBus/useEventChannel.ts
import { useEventStore } from './eventStore';

export function useEventChannel<
  EMap extends Record<string, unknown>
>() {
  const setEvent = useEventStore((s) => s.setEvent);

  function trigger<K extends keyof EMap>(
    type: K,
    payload: EMap[K]
  ) {
    setEvent({ type: type as string, payload });
  }

  function subscribe<K extends keyof EMap>(
    type: K,
    handler: (payload: EMap[K]) => void
  ) {
    return useEventStore.subscribe(
      (state) => state.lastEvent,
      (latest) => {
        if (latest?.type === type) {
          handler(latest.payload as EMap[K]);
        }
      }
    );
  }

  return { trigger, subscribe };
}
懒加载组件
import { useEffect, useState } from "react"

export function useLazyComponent<T>(loader: () => Promise<{ default: T }>) {
  const [Component, setComponent] = useState<T | null>(null)

  useEffect(() => {
    let mounted = true
    loader().then(mod => {
      if (mounted) setComponent(mod.default)
    })
    return () => {
      mounted = false
    }
  }, [])

  return Component
}


// 示例
const UserForm = useLazyComponent(() => import("./UserFormView"))
return showForm && UserForm ? <UserForm ... /> : null

@bohrium/domains

业务层:上图中的业务逻辑层+业务UI层。平台无关

domains 翻译为 “领域”

当前用作 Interface、type、enum、constant 可能不太符合

业务包更符合语义

├── bohrium-domains
│   ├── biz/                 # 各个业务自己的逻辑
│   │  ├── knowledge-base/        
│   │  │   ├── home-page/   # 模块 1(以页面/功能划分)
│   │  │   │   ├── ui/  
│   │  │   │   ├── components/
│   │  │   │   ├── stores/
│   │  │   │   ├── hooks/
│   │  │   │   ├── utils/
│   │  │   │   ├── types/
│   │  │   │   └── constants/
│   │  │   ├── share-page/  # 模块 2(以页面/功能划分)
│   │  │   ├── ui/          # 当前业务通用的无状态、无副作用 UI
│   │  │   ├── components/  # 当前业务通用的有状态和副作用组件
│   │  │   ├── stores/      # 业务通用 zustand
│   │  │   ├── hooks/       # 业务通用
│   │  │   ├── utils/       # 业务通用
│   │  │   ├── types/       # 业务通用
│   │  │   ├── constants/   # 业务通用
│   │  │   ├── apis/        # 当前业务的所有的 api 都放这里,不分散到子模块
│   │  │   └── events/      # 事件总线的监听和触发统一到这里,不要散布到各处
│   │  ├── ai-search/ 
│   │  └── global-sidebar/                
│   ├── shared/              # 多个业务共享
│   │  ├── paper-card/      # 共享模块 1
│   │  ├── └── ui、stores、hooks ……
│   │  ├── constants/       # 如:后端返回的 code 码
│   │  └── ui、stores、hooks ……
│   ├── locales/             # i18n  

状态管理使用 zustand,去除所有 Context、Redux

biz

各个业务有自己的 UI、stores、hooks、utils、apis、types、constants

不同业务之间不要强耦合,尽量不直接 import 其他业务的代码,可以:

shared

多个业务都用到的组件、方法等

如:文献卡片、收藏弹窗

@bohrium/space

平台层

├── bohrium-space
│   ├── router/               
│   ├── pages/
│   ├── config/
│   ├── App.tsx
│   ├── white-screen-check.ts
│   ├── bootstrap.ts
│   ├── index.less

平台层如何复用业务层?

多个平台自由引用组合业务层代码,如:web/h5可以使用业务层UI/component;mobile可能没有DOM,无法使用UI/component,但可以使用 utils、icon……

UI VS. component

UI

纯函数:(props) -> JSX:输入的 props 相同,输出的 JSX 永远相同

输入只靠 props,内部没有自己的 State,也不产生任何副作用

props 分为:输入、输出事件(onClick)

components

可以有自己状态,可以产生副作用

示例

一眼区分

UI 和 components 放到不同的文件夹下

Import 的代码要想知道是 UI/components 需要看引入路径,还是比较麻烦的

建议所有UI都以 UI 结尾,components 无需特殊标识,这样就可以一眼区分了

如: